Skip to main content

Zustand and Axios

以下文件描述的前因後果請參閱 Introduction

安裝

pnpm add zustand immer axios

建立所需的 Slice

相較於 Redux Toolkit, 不需要使用 context provider 將 store 傳遞下去, 便可以在全域讀取 Zustand 的 props

雖然 Redux Toolkit 可以透過 RTK 快速建立 storeaction, 但仍要寫不少的前置 code, 而在 Zustand 中只要使用 create() 就能快速建立 storeaction.

Zustand 是基於 React 的 Hook API 所設計, 使用上更加簡易自然, 要讀取 props 或 dispatch action 都可以直接使用 create() 建立的 hook 就可以將 props 跟 action 從 store 中讀取出來

auth.slice.ts
import { isEmpty } from 'lodash-es';
import { NavigateFunction } from 'react-router-dom';
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

import { useMainStore } from '@/store';
import { instance } from '@/utils/request.utility.ts';
import { appendStr } from '@/utils/string.utility.ts';
import { saveAccessTokenCookie } from '@/utils/token.utility.ts';

export interface AuthSlice {
auth: {
stVerifyQuery: (params: { st: string; navigate: NavigateFunction; search: string }) => Promise<void>;
};
}

export const authSlice: StateCreator<AuthSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer(() => ({
auth: {
stVerifyQuery: async ({ st, search, navigate }) => {
try {
const { data } = await instance({
url: `/auth/access-token/${st}`,
method: 'GET'
});
saveAccessTokenCookie(data);
const params = new URLSearchParams(search);
const queriedStr = Object.fromEntries([...params]);
delete queriedStr.st;
const redirectStr = isEmpty(queriedStr) ? '' : appendStr('', queriedStr);
navigate(`${window.location.pathname}${redirectStr}`, { replace: true });
} catch (data: any) {
useMainStore.getState().error.onErrorDataChange('stVerifyError', data);
}
navigate(window.location.pathname, { replace: true });
useMainStore.getState().fetchStatus.onFetchStatusChange('isStVerifying', false);
}
}
}))
);
error.slice.ts
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface ErrorType {
key?: string;
message: string;
statusCode: number;
}
export interface ErrorSlice {
error: {
errorData: { [key: string]: ErrorType };
onErrorDataChange: (field: string, value: ErrorType) => void;
};
}
export const errorSlice: StateCreator<ErrorSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer((set: (fn: (state: ErrorSlice) => void) => void) => ({
error: {
errorData: {},
onErrorDataChange: (field: string, value: ErrorType) => {
set(state => {
state.error.errorData[field] = value;
});
}
}
}))
);
fetch-status.slice.ts
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
export interface FetchStatusSlice {
fetchStatus: {
fetchStatus: { [key: string]: boolean };
onFetchStatusChange: (field: string, value: boolean) => void;
};
}
export const fetchStatusSlice: StateCreator<FetchStatusSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer((set: (fn: (state: FetchStatusSlice) => void) => void) => ({
fetchStatus: {
fetchStatus: {},
onFetchStatusChange: (field, value) => {
set(state => {
state.fetchStatus.fetchStatus[field] = value;
});
}
}
}))
);
user.slice.ts
import { StateCreator } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useMainStore } from '@/store';
import { instance } from '@/utils/request.utility.ts';
export interface User {
email: string;
}
export interface UserSlice {
user: {
currentUser: User | undefined;
fetchProfile: () => void;
onCurrentUserChange: (value: User | undefined) => void;
};
}
export const currentUserSlice: StateCreator<UserSlice, [], [['zustand/devtools', never], ['zustand/immer', never]]> = devtools(
immer(set => ({
user: {
currentUser: undefined,
fetchProfile: async () => {
try {
const { data } = await instance({
url: `/auth/profile`,
method: 'GET'
});
set(state => {
state.user.currentUser = data;
});
} catch (error) {
console.error(error);
}
useMainStore.getState().fetchStatus.onFetchStatusChange('isProfileFetching', false);
},
onCurrentUserChange: value => {
set(state => {
state.user.currentUser = value;
});
}
}
}))
);
  • 我們可以同 Redux Toolkit 一樣先切成各個 slice, 然後再透過 create() 來建立 store
  • 在 store 中可以使用 set() 更新 state, 使用 get() 取得 state
  • 另外當我們需要更新 nested object 的時候, 可以透過 immer middleware 來幫助我們更方便的更新 state
  • Zustand 可以結合 redux devtool (devtools) 來幫助我們 debug
store.ts
import { StoreApi, UseBoundStore } from 'zustand';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { AuthSlice, authSlice } from '@/store/slice/auth.slice.ts';
import { ErrorSlice, errorSlice } from '@/store/slice/error.slice.ts';
import { FetchStatusSlice, fetchStatusSlice } from '@/store/slice/fetch-status.slice.ts';
import { currentUserSlice, UserSlice } from '@/store/slice/user.slice.ts';

export type MainStoreType = AuthSlice & ErrorSlice & FetchStatusSlice & UserSlice;

// auto-generated selectors provided by Zustand
type WithSelectors<S> = S extends { getState: () => infer T } ? S & { use: { [K in keyof T]: () => T[K] } } : never;
const createSelectors = <S extends UseBoundStore<StoreApi<object>>>(_store: S) => {
const store = _store as WithSelectors<typeof _store>;
store.use = {};
for (const k of Object.keys(store.getState())) {
(store.use as any)[k] = () => store(s => s[k as keyof typeof s]);
}
return store;
};

export const useMainStore = createSelectors(
createWithEqualityFn<MainStoreType>(
(...s) => ({
...authSlice(...s),
...currentUserSlice(...s),
...errorSlice(...s),
...fetchStatusSlice(...s)
}),
shallow
)
);
  • shallow 用於比較前後兩次的 state, 在 selector 與 state 之間的 reference 不同但 value 相同的情況下防止不必要的 re-render

  • createWithEqualityFn() 取代 create(), 並在第二個參數帶入 shallow 使用

  • 或者你也可以在 component 中使用 useShallow, 兩者皆可 (relative issue):

    App.tsx
    const { currentUser, fetchProfile, fetchStatus, onFetchStatusChange, stVerifyQuery } = useMainStore(
    useShallow((store: MainStoreType) => {
    return {
    currentUser: store.user.currentUser,
    fetchProfile: store.user.fetchProfile,
    fetchStatus: store.fetchStatus.fetchStatus,
    onFetchStatusChange: store.fetchStatus.onFetchStatusChange,
    stVerifyQuery: store.auth.stVerifyQuery
    };
    })
    );

建立 API Request

Zustand 如果要發送一個 HTTP request, 可以透過 axios 來發送, 並且可以在 action 中直接使用 set() 來更新 state

request.utility.ts
import axios, { AxiosInstance, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import { casApiUrl } from '@/config.ts';
import { useMainStore } from '@/store';
import { ErrorType } from '@/store/slice/error.slice.ts';
import { getAccessTokenByCookie, removeAccessTokenCookie, saveAccessTokenCookie } from '@/utils/token.utility.ts';

const instance: AxiosInstance = axios.create({
baseURL: casApiUrl,
withCredentials: true
});

instance.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = getAccessTokenByCookie();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}

return config;
},
error => Promise.reject(error)
);

instance.interceptors.response.use(
(response: AxiosResponse) => {
return response.data;
},
async error => {
if (error.response) {
const data = error.response.data.error as ErrorType;

switch (data.key) {
case 'ACCESS_TOKEN_IS_EXPIRED':
removeAccessTokenCookie();
try {
return instance({
url: `/auth/access-token/renew`,
method: 'GET'
}).then((response: AxiosResponse) => {
const newAccessToken = response.data;
saveAccessTokenCookie(newAccessToken);
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return instance.request(error.config);
});
} catch (renewError) {
return Promise.reject(renewError);
}
case 'REFRESH_TOKEN_IS_EXPIRED':
useMainStore.getState().error.onErrorDataChange('profile', data);
useMainStore.getState().user.onCurrentUserChange(undefined);
return Promise.reject(data);
default:
return Promise.reject(data);
}
} else {
return Promise.reject(error);
}
}
);
export { instance };
  • instance.interceptors.requestinstance.interceptors.response 用於攔截 request 與 response
  • 上面的 Api 邏輯同 Redux Toolkit 這篇createApi Slice

在 Component 中使用

在 Component 中可以直接從 store 中依據你的 hook 傳遞的 selector 來取得你想要的 state 或是 action 來使用

use-auth.tsx
// ...
export const useAuth = () => {
const { currentUser, fetchProfile, fetchStatus, onFetchStatusChange, stVerifyQuery } = useMainStore((store: MainStoreType) => {
return {
currentUser: store.user.currentUser,
fetchProfile: store.user.fetchProfile,
fetchStatus: store.fetchStatus.fetchStatus,
onFetchStatusChange: store.fetchStatus.onFetchStatusChange,
stVerifyQuery: store.auth.stVerifyQuery
};
});
// ...
};

我們可以透過 Zustand 提供的 Auto Generating Selectors 方法來自動生成選擇器 (selectors), 可以讓你從狀態中直接選取某些部分, 而不必每次都手動撰寫這些 selectors (上面 store.ts 的 line 22-32), 在 Component 中就可以透過 use 來取得對應 selector 中的狀態或 action

home.tsx
// ...
const HomePage: FC = () => {
const { currentUser } = useContext(GlobalContext);
// error 是我們在 error.slice.ts 中定義的 key, 裡面包含了 errorData 及 onErrorDataChange
const { errorData } = useMainStore.use.error();

return (
<div>
<h1>HomePage</h1>
{/*...*/}
{!isEmpty(errorData) && (
<div className="text-red-500">
<div>
Error:
{Object.keys(errorData).map(key => {
return <div key={key}>{errorData[key].message}</div>;
})}
</div>
</div>
)}
</div>
);
};
export default HomePage;

Context Api 的部分同 Redux Toolkit 這篇 一樣, 只差在怎麼取得 store 中的 props

其他的設定或 Demo 也都同樣, 可直接參考上篇